feat(npu): NPU-native FLM embedder (embed-gemma-300m-FLM) for the NPU profile (#1744)#1761
Conversation
SummarySolid, well-scoped fix for the NPU embedder thrash (#1744): moving the NPU profile onto the FLM-native One thing the author should know before merge: the embedder-change invalidation protects every swap except the first one — an existing NPU user upgrading onto the FLM embedder has nomic vectors but no marker yet, so the swap goes undetected. That's the exact silent-corruption case this PR is built to prevent, just at the migration boundary. Details below. Issues Found🟡 Important — First migration onto the FLM embedder isn't invalidated (
|
… profile (#1744) On the NPU chat profile the chat model runs on the FLM/NPU backend while the embedder (nomic GGUF) runs on Vulkan/llama.cpp. On a shared-memory Ryzen AI APU loading the Vulkan embedder reclaims the memory the FLM model holds, so the two are never co-resident and every chat turn swaps backends — an endless load/unload loop (#1676). Make the embedder device-scoped: the NPU profile now uses the FLM-native embed-gemma-300m-FLM so chat and embeddings both live on the FLM/NPU backend. GPU/CPU keep the GGUF nomic embedder unchanged. - registry: DeviceConfig gains embedding_model (single source of truth next to the chat model/recipe/backend); get_embedding_model_for_device() resolves it. - lemonade_client: register embed-gemma-300m-FLM in MODELS. - init_command: NPU profile downloads the FLM embedder (pulled by name, no recipe — built-in FLM model, #1655-safe). - memory: derive the embedding dimension from the live embedder instead of a hardcoded 768, and invalidate stored vectors keyed on the embedder id — EmbeddingGemma is also 768-dim but a different vector space, so a dim-only check can't detect the swap. - chat agent: select the embedder from config.device for both RAG and memory. Closes #1744. Part of #1746.
… the DB (#1744) Code review found the NPU profile's primary surface (gaia chat --ui) still hardcoded the nomic embedder/dim in the memory router, which re-triggered the #1676 thrash and reintroduced the cross-space corruption the marker was added to prevent. - routers/memory.py: rebuild-embeddings re-embeds with the stamped embedder (store.get_embedder_id() or nomic default), not a hardcoded model; reconcile derives the FAISS dim from the stored vectors instead of a hardcoded 768. - memory_store.py: store the embedder id in a SQLite `meta` table instead of a sidecar file — atomic with the data and visible to every connection (agent + UI router run different code paths against the same DB). set_embedder_id now fails loudly on write error instead of swallowing it. - tests: cover the router using the stamped embedder, reconcile dim derivation, the zero-length-vector guard, and the full clear -> backfill round trip.
…ss-optional CI green-up for #1744: - registry: replace the `DeviceConfig.__dataclass_fields__[...].default` lookup (pylint E1101) with a shared `DEFAULT_EMBEDDING_MODEL` module constant. - tests: the CI unit-test env has no faiss-cpu, so guard the FAISS-index dim assertion and the standalone-reconcile test with pytest.importorskip("faiss"). The dynamic-dim derivation itself is asserted without faiss.
4462a02 to
ab1e207
Compare
# Conflicts: # hub/agents/python/chat/gaia_agent_chat/agent.py # src/gaia/agents/registry.py
The suite only had mocked unit tests plus a manual hardware checklist, so nothing exercised embed-gemma-300m-FLM on a real NPU. Add an integration test that runs the FLM embedder on a live FLM/NPU-enabled Lemonade Server and proves the #1744 guarantee end to end: valid non-zero vectors at the expected dim, deterministic + distinct encodings, and — the core of the change — the FLM chat model and FLM embedder staying co-resident with no NPU<->Vulkan eviction. The FLM model exists only on a Lemonade Server running the FLM backend on Ryzen AI hardware, so every test gates on the live catalog and skips cleanly on CPU/GPU/Vulkan boxes and in non-NPU CI (verified: 5 skipped, no errors, against a Vulkan server). A strix-halo workflow runs it on real NPU hardware.
|
🟡 The new NPU embedder workflow pins 🔍 Technical details
- uses: actions/checkout@v6 # ← should be @v7 to match every other workflowEvery other workflow in the repo ( Fix: change |
Generalize the embedder hardware test to validate EmbeddingGemma 300M on both backends GAIA uses: the GGUF/llamacpp variant (user.embeddinggemma-300m-GGUF, the non-NPU default replacing nomic, #1952) and the FLM/NPU variant (embed-gemma-300m-FLM, #1744). The vector-level checks — 768-dim, finite, non-zero, deterministic, distinct-texts-differ — are shared and parametrized across both backends; the chat+embedder co-residency check stays FLM-specific. Each backend gates on the live Lemonade catalog and skips when absent, so the GGUF variant runs on GPU/Vulkan boxes and the stx CI runner while FLM runs on Ryzen AI NPU runners — neither produces a false failure where its model isn't present. Verified locally: GGUF variant passes against a real EmbeddingGemma GGUF model (4 passed), FLM variant skips cleanly with no server error.
|
🔴 The new CI workflow will fail on every run — it invokes Fix: rename the file to 🔍 Technical details
python -m pytest tests/test_npu_flm_embedder_hw.py -v -rs --tb=short
if ($LASTEXITCODE -ne 0) { throw "NPU FLM embedder tests failed" }The - 'tests/test_npu_flm_embedder_hw.py'Actual file on disk: The stale docstring is in |
#1761 shipped the NPU FLM hardware test as tests/test_gemma_embedder_hw.py but its workflow (test_npu_embedder.yml) and the file's own docstring both reference tests/test_npu_flm_embedder_hw.py — so the job failed with 'file or directory not found'. Rename the file to the intended name.
Two failures introduced on main by the #1748 (embedding cache) × #1761 (per-instance embedder) interaction and #1371 (schedule), which this branch inherits via the merge: 1. MemoryMixin._embed_text/_get_embedder referenced self._embedding_model directly, which is only set in init_memory — so the embedding-cache unit tests (bare mixin) failed with AttributeError, and the cache key used the module default while the embed call used the instance model (mismatched for the NPU FLM embedder). Resolve the active model/dim via helpers that fall back to the module default, and key the cache by the same resolved model. 2. schedule/daemon.py reimported datetime inside next_fire_time (W0404) — already imported module-level. Removed.
Why this matters
On GAIA's NPU chat profile the chat model runs on the FLM/NPU backend while the embedder (
nomic-embed-text-v2-moe-GGUF) runs on Vulkan/llama.cpp. On a shared-memory Ryzen AI APU, loading the Vulkan embedder reclaims the memory the FLM model holds, so the two are never co-resident — every chat turn swaps backends in an endless load/unload loop (#1676). After this change the NPU profile uses the FLM-nativeembed-gemma-300m-FLM, so chat and embeddings both live on the FLM/NPU backend and the NPU↔Vulkan eviction is gone. GPU/CPU profiles are unchanged.The embedder choice is now a property of the device (
DeviceConfig.embedding_model), co-located with the chat model/recipe/backend — a single source of truth. The reporter (@soulafein83) verifiedGemma-4-E4B FLM + embed-gemma-300m-FLMco-resident at 32k on a Ryzen AI 7 350 / 16 GB.Notable design points
embed-gemma-300m-FLMis 768-dim (same number as nomic) but a different vector space, so a dim-only check can't detect the swap. Memory invalidates stored vectors keyed on the embedder id and re-embeds on the next backfill. RAG needs no change — its on-disk cache stores parsed chunks, not vectors, and rebuilds the FAISS index in-memory each session.metatable, not a sidecar file — atomic with the data it describes and visible to every connection. The agent and the UI memory router open the same DB from different code paths.gaia chat --uiis the NPU profile's primary surface; its rebuild-embeddings/reconcile endpoints now reuse the stamped embedder and derive the dim from the stored vectors instead of hardcoding nomic/768 (which would re-trigger the thrash and mix vector spaces).embed-gemma-300m-FLMis a built-in FLM model, pulled by name with norecipe=(same path asgemma4-it-e2b-FLM).Test plan
Automated (CI):
pytest tests/unit/test_npu_flm_embedder.py— resolver, MODELS entry, init manifest, dynamic-dim, zero-length-vector guard, embedder-change invalidation, full clear→backfill round-trip, marker round-trippytest tests/unit/test_memory_router.py— rebuild uses the stamped embedder (falls back to nomic when unstamped); reconcile derives dim from stored vectorspytest tests/unit/test_init_command.py::TestDownloadModels— NPU profile pulls both FLM models by name, no recipepytest tests/unit/test_memory_mixin.py tests/unit/test_memory_store.py— no regressions (1 pre-existing, environment-dependent router failure unrelated to this change)python util/lint.py --black --isortHardware validation — pending, on-NPU only (cannot run in CI; @kovtcharov to verify locally):
gaia init --profile npuon a fresh box pullsgemma4-it-e2b-FLMandembed-gemma-300m-FLMwith norecipe=400 (test from a cold cache, not a warm one — gaia init --profile npu fails with status 400 (missing user. prefix for model name) #1655)embed-gemma-300m-FLM's actual embedding dim on hardware (expected 768; derived dynamically regardless)gaia eval agenton the NPU profileCloses #1744. Part of #1746.